Skip to content

Conversation

@jonodrew
Copy link
Contributor

@jonodrew jonodrew commented Jan 29, 2026

This code should close #2383 , and form a decent basis for other tasks in that group.

It collates the users who've not accepted a workshop invite in the last three months and who haven't already been chased, and sends them a chaser email.

@jonodrew jonodrew force-pushed the email-users-code branch 3 times, most recently from 9fde866 to 8c8f601 Compare February 4, 2026 17:06
@jonodrew jonodrew marked this pull request as ready for review February 4, 2026 17:18
@jonodrew jonodrew requested a review from olleolleolle February 4, 2026 17:22
@jonodrew jonodrew marked this pull request as draft February 4, 2026 17:22
@jonodrew
Copy link
Contributor Author

jonodrew commented Feb 4, 2026

Alright, code mostly written. Now just need to figure out how to set it to be run on a regular basis

This starts us down the path of using Active Job, which is the preferred mechanism for jobs in Rails 7.1.

Signed-off-by: jonathan.kerr <3410350+jonodrew@users.noreply.github.com>
I've taken the copy from Kimberly and put it into HTML, as we do with the rest of our emails

Signed-off-by: jonathan.kerr <3410350+jonodrew@users.noreply.github.com>
This adds an additional method to the MemberMailer class. It's currently called #chaser and it sends...the chaser email.

In the next commit I'll call it from a Job, or maybe a Service that's called from a Job

Signed-off-by: jonathan.kerr <3410350+jonodrew@users.noreply.github.com>
This is the setup required for using ActiveJob and its queue, and checking if things were enqueued.

Signed-off-by: jonathan.kerr <3410350+jonodrew@users.noreply.github.com>
This was an oversight when creating the related table, and fixes it

Signed-off-by: jonathan.kerr <3410350+jonodrew@users.noreply.github.com>
This outlines a single test for sending the chaser email. It also creates the relevant Job and Service.

When users haven't attended a workshop they were invited to in the last three months _and_ haven't already been chased, they'll be sent a follow up email

Signed-off-by: jonathan.kerr <3410350+jonodrew@users.noreply.github.com>
To note - the first time we run this, there's likely to be a massive spike in emails.

To set this in Heroku, we'll need to log into the interface and call it as `rake chaser:three_months`

Signed-off-by: jonathan.kerr <3410350+jonodrew@users.noreply.github.com>
@jonodrew jonodrew marked this pull request as ready for review February 10, 2026 13:34
Copy link

@octoberclub octoberclub left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great so far. Got it to work with some tweaks. Have also made some suggestions.

Feel free to bounce any comments back to me. Happy to fork and do another PR to your branch myself if that's easier?

Runthrough of what I checked

  • Browsed through the code, and compared with my own solution (in a local branch)
  • Used AI with superpowers plugin to check the changes and compare
  • Did some testing manually

** AI prompt **

 Can you check through the changes to see if any issues come up? Specifically, I wondered if the query in the send_chaser method needed to also check subscriptions in case it accidentally includes unsubscribed users? Anything else to note?

Response:

Findings

High: The send_chaser query does not check active subscriptions or student role, so it can include members who have unsubscribed from all groups or are coaches. If the intent is “students who haven’t attended in 3 months,” it should scope to student subscriptions (e.g., joins(:groups).merge(Group.students) or joins(:subscriptions) plus role filtering). File: [three_month_email_service.rb](https://file+.vscode-resource.vscode-cdn.net/Users/michellesteele/.vscode/extensions/openai.chatgpt-0.4.71-darwin-arm64/webview/#).
High: The query logic is based on workshop_invitations.created_at and attended: false, which doesn’t actually mean “has not attended in 3 months.” It will include members who have attended recently but also have a non-attended invitation in the last 3 months. It should look for members with no attended workshops since a cutoff, using workshops.date_and_time and WorkshopInvitation.attended scoped to students. File: [three_month_email_service.rb](https://file+.vscode-resource.vscode-cdn.net/Users/michellesteele/.vscode/extensions/openai.chatgpt-0.4.71-darwin-arm64/webview/#).
Medium: The email template HAML is mis-indented, so the %p tags render empty and the text is output as plain text lines. You likely want the lines indented under each %p. File: [three_month_chaser.html.haml](https://file+.vscode-resource.vscode-cdn.net/Users/michellesteele/.vscode/extensions/openai.chatgpt-0.4.71-darwin-arm64/webview/#).
Low: [members.each](https://file+.vscode-resource.vscode-cdn.net/Users/michellesteele/.vscode/extensions/openai.chatgpt-0.4.71-darwin-arm64/webview/#) in send_chaser could be find_each to avoid loading a large set into memory. File: [three_month_email_service.rb](https://file+.vscode-resource.vscode-cdn.net/Users/michellesteele/.vscode/extensions/openai.chatgpt-0.4.71-darwin-arm64/webview/#).
Direct answer to your question
Yes. As written, send_chaser can include unsubscribed users because it never checks subscriptions or groups. If you only want active student subscribers, it needs that scope.

If you want, I can propose a corrected query that matches the “no attendance in 3 months” requirement and respects subscriptions.

Testing:
I tested locally using this in the config which output the emails as separate files into tmp/mails folder in the container.

config.action_mailer.delivery_method = :file
config.action_mailer.file_settings = { location: Rails.root.join('tmp', 'mails') }

I created some members who had not attended in three months via rails console
docker compose exec web bundle exec rails c

member = Fabricate(:member, auth_services: [
  Fabricate(:auth_service, uid: SecureRandom.hex(8))
])
chapter = Fabricate(:chapter)
group = Fabricate(:group, name: 'Students', chapter: chapter)
Fabricate(:subscription, member: member, group: group)

old_workshop = Fabricate(:workshop, chapter: chapter, date_and_time: 4.months.ago)
Fabricate(:workshop_invitation, member: member, workshop: old_workshop, attended: false, role: 'Student')

Then I ran the service

ThreeMonthEmailService.send_chaser

Tweaks needed for testing manually:
I had to change send_chaser a bit to get it to work (might be to do with my setup though)

  1. Update the query to check for attended: nil instead of false
  2. Change to deliver_now

Then you can find the email output in tmp/mails


private

def log_sent_email

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea to put it in a shared concern for future.

Comment on lines +5 to +10
members = Member.joins(:workshop_invitations)
.left_joins(:member_email_deliveries)
.where(workshop_invitations: { attended: false })
.where("workshop_invitations.created_at >= ?", 3.months.ago.beginning_of_day)
.where(member_email_deliveries: { id: nil })
.distinct

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There might be a bit more to the query as it has to exclude certain members

  • unsubscribed members
  • possibly coaches(?) One to check with Kimberley?
  • banned members
  • not accepted terms and conditions

Also the logic here only checks created_at>=3 months and attended:false which would still include members who did attend in the last three months but also have a non-attended workshop in that period

Suggested change
members = Member.joins(:workshop_invitations)
.left_joins(:member_email_deliveries)
.where(workshop_invitations: { attended: false })
.where("workshop_invitations.created_at >= ?", 3.months.ago.beginning_of_day)
.where(member_email_deliveries: { id: nil })
.distinct
recent_attendees = Member.joins(:workshop_invitations)
.merge(
WorkshopInvitation.attended.to_students
.joins(:workshop)
.where('workshops.date_and_time >= ?', 3.months.ago.beginning_of_day)
)
.distinct
members = Member.not_banned
.accepted_toc
.joins(:groups)
.merge(Group.students)
.left_joins(:member_email_deliveries)
.where(member_email_deliveries: { id: nil })
.where.not(id: recent_attendees.select(:id))
.distinct

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I tested (with fake data), the attended column was nil rather than false which also failed the query. I'm not sure if that's the same in our live db

.where(member_email_deliveries: { id: nil })
.distinct
return if members.empty?
members.each do |member|

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use find_each to lower the amount of records read into memory in one go

Suggested change
members.each do |member|
members.find_each do |member|

Comment on lines +1 to +21
%h1 Hi #{@member.name},

%p
We’ve noticed you haven’t been to a codebar workshop in a little while, and we just wanted to check in. We know life gets busy, but we’d love to understand how things are going for you and whether there’s anything we can do to make it easier or more valuable for you to join again.
%p
If you have a minute, could you please share your thoughts in this short form? 👉 https://forms.gle/tEETvC3zYP9mcLar7

%p
Or, if you’re thinking about coming back soon, we’ve got some great upcoming workshops and events you might like to join 👉https://codebar.io/events/

%p
Your feedback really helps us make codebar more welcoming and useful for everyone in our community 💜

%p
We’d love to see you again soon!

%p
#{"-- "}
%br
Warmly,
The Codebar Team

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks as if the multiline text under each %p section needs indenting for it to display. At least this is how .slim files work and I'm assuming .haml works the same.

As an aside, would be better to move everything to .html.erb eventually? Much better format, although it's beyond this issue, might be worth discussing in future?

Suggested change
%h1 Hi #{@member.name},
%p
We’ve noticed you haven’t been to a codebar workshop in a little while, and we just wanted to check in. We know life gets busy, but we’d love to understand how things are going for you and whether there’s anything we can do to make it easier or more valuable for you to join again.
%p
If you have a minute, could you please share your thoughts in this short form? 👉 https://forms.gle/tEETvC3zYP9mcLar7
%p
Or, if you’re thinking about coming back soon, we’ve got some great upcoming workshops and events you might like to join 👉https://codebar.io/events/
%p
Your feedback really helps us make codebar more welcoming and useful for everyone in our community 💜
%p
We’d love to see you again soon!
%p
#{"-- "}
%br
Warmly,
The Codebar Team
%h1 Hi #{@member.name},
%p
We’ve noticed you haven’t been to a codebar workshop in a little while, and we just wanted to check in. We know life gets busy, but we’d love to understand how things are going for you and whether there’s anything we can do to make it easier or more valuable for you to join again.
%p
If you have a minute, could you please share your thoughts in this short form? 👉 https://forms.gle/tEETvC3zYP9mcLar7
%p
Or, if you’re thinking about coming back soon, we’ve got some great upcoming workshops and events you might like to join 👉https://codebar.io/events/
%p
Your feedback really helps us make codebar more welcoming and useful for everyone in our community 💜
%p
We’d love to see you again soon!
%p
#{"-- "}
%br
Warmly,
The Codebar Team

end
end

describe "#chaser" do

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice spec.

@@ -0,0 +1,43 @@
RSpec.describe ThreeMonthEmailService, type: :service do

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we could add a few scenarios around different types of attendance - like people who had not attended one workshop but then attended the next one in a previous 3 months.

Also check we don't send out for banned, or unsubscribed members.

include EmailHeaderHelper
include EmailDelivery

after_action :log_sent_email, only: [:chaser]

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might want to log only once the email is sent

Suggested change
after_action :log_sent_email, only: [:chaser]
after_deliver :log_sent_mail , only: [:chaser]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

New automated email for students who have not attended a codebar workshop in 3 months

3 participants